Skip to content

fix(ai): coalesce adjacent user and toolResult turns for Anthropic - #1126

Merged
code-yeongyu merged 5 commits into
code-yeongyu:mainfrom
codeg-dev:fix/anthropic-adjacent-user-tool-result-coalesce
Aug 29, 2026
Merged

fix(ai): coalesce adjacent user and toolResult turns for Anthropic#1126
code-yeongyu merged 5 commits into
code-yeongyu:mainfrom
codeg-dev:fix/anthropic-adjacent-user-tool-result-coalesce

Conversation

@codeg-dev

Copy link
Copy Markdown
Contributor

Summary

When an agent turn is interrupted or aborted after a toolResult (e.g. process termination while waiting for an async background task or tool completion), or when consecutive user messages are dispatched, convertMessages in anthropic-messages.ts emitted adjacent role: 'user' messages into the Anthropic payload.

Anthropic API rejects payloads with consecutive user turns with HTTP 400 (messages: roles must alternate between 'user' and 'assistant', but found multiple 'user' roles in a row), causing unrecoverable session stalls on reload.

Changes

  • Introduce appendUserBlocks in anthropic-messages.ts to coalesce consecutive user messages and trailing toolResult user blocks into a single alternating user turn.
  • Preserve content block ordering (tool_result blocks followed by user prompt text blocks) and cache control marker semantics.
  • Add regression test suite in packages/ai/test/anthropic-adjacent-user-tool-result-coalesce.test.ts verifying:
    1. User message following toolResult is coalesced into a single user turn containing both tool_result and text blocks.
    2. Consecutive user messages coalesce into a single user turn.
    3. Strict role alternation (user -> assistant -> user) is preserved.

When a session turn is interrupted after a toolResult or when multiple
user messages arrive consecutively, convertMessages produced consecutive
role: 'user' messages in the Anthropic payload. Anthropic rejects this
with HTTP 400 ('roles must alternate between user and assistant').

This change adds appendUserBlocks to coalesce adjacent user messages and
toolResult turns into a single valid user turn while maintaining proper
content block ordering and cache control semantics.

Co-authored-by: Code_G <288527233+codeg-dev@users.noreply.github.com>
Signed-off-by: Code_G <288527233+codeg-dev@users.noreply.github.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 72f73866e6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/ai/src/api/anthropic-messages.ts Outdated
@codeg-dev

Copy link
Copy Markdown
Contributor Author

반영했습니다. packages/ai/src/changes.md에 네 개 필수 섹션과 정확한 대상 경로를 추가했고, node scripts/check-pr-changelog.mjs --base upstream/main --labels no-changelog가 통과합니다. 수정 커밋: 662cc79

@code-yeongyu code-yeongyu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 1 review

B1. The PR currently fails the required Changelog gate. Running node scripts/check-pr-changelog.mjs --base main on this exact checkout reports runtime source changed without a CHANGELOG.md entry; the new packages/ai/src/changes.md entry only satisfies the separate tracker-coverage policy. Add a packages/ai/CHANGELOG.md Unreleased entry, or apply the no-changelog label if maintainers explicitly classify this as non-user-facing, before merge.

B2. This changes every plain string user turn, not just adjacent turns, from content: string to a one-element content-block array. Because every new user message is now constructed through appendUserBlocks, its lastParam.content string branch is unreachable in the current pipeline, so the old wire representation is never retained. anthropic-messages is shared by the first-party API and multiple Anthropic-compatible backends; this is an unrelated wire-format change with no compatibility coverage in the PR. Preserve the old string shape for standalone string messages and promote only when coalescing, or add explicit coverage and a compatibility decision for every supported backend.

Comment thread packages/ai/src/api/anthropic-messages.ts Outdated
Comment thread packages/ai/src/api/anthropic-messages.ts Outdated
@code-yeongyu

Copy link
Copy Markdown
Owner

Review 5058019802 blockers resolved in 0c7ee98:

  • B1: Added an Unreleased Fixed entry to packages/ai/CHANGELOG.md. node scripts/check-pr-changelog.mjs --base main passes, including changes.md coverage.
  • B2: Standalone string user messages retain content: string; conversion to text-block arrays occurs only when appending to an existing user turn during actual coalescing. Added compatibility coverage asserting the exact standalone wire shape, while existing adjacent/coalesced cases remain covered.

Evidence: the new B2 test failed first with the received one-element array wire shape, then the targeted suite passed 3/3 tests (and related Anthropic suites passed 23/23 tests). Final PR head is 0c7ee98 and remains MERGEABLE.

@code-yeongyu code-yeongyu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round-2 review of commit 0c7ee987dcd5bd642fc57ccf670c23495dee564a.

Round-1 blocker resolutions:

  • B1 RESOLVED. packages/ai/CHANGELOG.md now has a correctly formatted [Unreleased] / ### Fixed entry, and node scripts/check-pr-changelog.mjs --base main passes with both the release changelog and packages/ai/src/changes.md coverage.
  • B2 RESOLVED for the reported standalone-string regression. convertMessages preserves a standalone non-empty string as content: string when no emitted user turn exists to coalesce; it promotes to text blocks only when appending to an existing user turn. I also checked no-cache, system-prompt, empty/whitespace, three-or-more adjacent users, tool-result-only, and interrupted tool-result-plus-user paths. With cache retention enabled, the pre-existing cache-control pass intentionally promotes the final standalone string so it can carry cache_control; the added no-cache compatibility assertion fails if every standalone string regresses to an array. The focused Anthropic compatibility/cache/replay suites passed (55 tests, 4 skipped).

Fresh blocker:

  1. packages/ai/test/deferred-tools.test.ts > deferred tools > preserves tool output as sibling content after emitting references fails on this HEAD. The test fixture has user("Hello") -> assistant(tool_use) -> toolResult(s) -> user("Hello"). The new appendUserBlocks correctly treats the final user text as adjacent to the existing serialized user turn containing the tool results, so the received wire content is [tool_result, tool_result, sibling text, image, { type: "text", text: "Hello", cache_control: { type: "ephemeral" } }], while the existing assertion expects only the prior four entries. The extra final text/cache-marker block makes the test fail (the is_error: false fields shown in the diff are not the cause). This is HEAD-only: the test passes at the PR base da38dc52cd37a817db6a7ddacca7cd2f0fe7b546 and at origin/main f97db40079d6a2cb3e1e383586c551e90b6ee049; it fails on HEAD with 1 failed / 25 passed. Update the existing assertion to encode the intended coalesced wire shape, including the final text and marker, and rerun the package suite.

I inspected #1075 as well: it overlaps this same serializer/helper region and carries the cache-checkpoint variant, so any eventual integration must preserve the coalesced content and marker expectations together. The missing @earendil-works/pi-tui/dist/index.js entrypoint is environment-only and was not counted as a PR blocker: direct package resolution fails identically on the PR base, origin/main, and this checkout.

No other new blocker was found.

@code-yeongyu

Copy link
Copy Markdown
Owner

Round-2 blocker fixed in commit a2e234eae099d386952a3bf795f1ba9d5c26604f.

  • Updated deferred-tools.test.ts to assert the intended coalesced Anthropic user content shape: the final Hello text block with cache_control: { type: "ephemeral" } is a sibling in the existing tool-result user content array.
  • RED: focused test failed with 1 failed / 25 skipped and showed the missing fifth text/cache block.
  • GREEN: focused test passed (1/1), and the full deferred-tools.test.ts passed (26/26).
  • Full packages/ai suite: 2,394 passed, 869 skipped; 2 unrelated pre-existing failures remain (missing @earendil-works/pi-tui entrypoint and cursor-agent heartbeat expectation).
  • Changelog gate passed with --base origin/main.
  • Package typecheck was attempted and is blocked by the existing missing @earendil-works/pi-telemetry workspace target.

gh pr view --json headRefOid now reports head a2e234eae099d386952a3bf795f1ba9d5c26604f.

@code-yeongyu

Copy link
Copy Markdown
Owner

WORKING: review-1126-r3 - targeted assertion and serializer verification.

@code-yeongyu code-yeongyu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round-3 review of commit a2e234eae099d386952a3bf795f1ba9d5c26604f.

Round-2 blocker resolution: VERIFIED. packages/ai/test/deferred-tools.test.ts now includes the fifth coalesced block, { type: "text", text: "Hello", cache_control: { type: "ephemeral" } }. This is the intended wire shape, not only an observed output: convertToolResult emits all tool_result blocks before displaced sibling content, and appendUserBlocks appends the following user text after those existing blocks. The merged #1075 checkpoint test independently codifies the same [tool_result, text-with-cache-control] order for an interrupted tool turn. The standalone-string compatibility and adjacent-turn coverage remain present.

I ran Vitest from the PR worktree against deferred-tools.test.ts, anthropic-adjacent-user-tool-result-coalesce.test.ts, anthropic-provider-native-replay.test.ts, anthropic-tool-reference-integrity.test.ts, and anthropic-final-tool-pair-guard.test.ts: 5 files passed, 50 tests passed.

Fresh blocker - #1075 composition requires a re-merge before approval:

  1. Merged #1075 is 54a15cf906d0bcf7933c3065ad743f450f22d449 on origin/main, while this PR remains based on da38dc52cd37a817db6a7ddacca7cd2f0fe7b546. GitHub currently reports this PR as mergeable: CONFLICTING.
  2. git merge-tree --write-tree --messages origin/main HEAD reports conflicts in both packages/ai/src/api/anthropic-messages.ts and packages/ai/CHANGELOG.md. The serializer conflict is semantic as well as textual: #1075 adds isToolLoopContinuation, markUserMessageCacheCheckpoint, and the rolling preceding-checkpoint pass, and changes the OAuth identity-marker condition; the current PR head does not contain those changes. Resolving by taking the PR side would drop #1075's checkpoint behavior, while taking the main side requires explicitly preserving this PR's coalesced assertion/test shape.

Please merge/rebase current origin/main into this PR, resolve the serializer and changelog conflicts while retaining both checkpoint logic and coalescing, and rerun the combined Anthropic/deferred-tools coverage. No other new blocker found.

REQUEST_CHANGES

@code-yeongyu

Copy link
Copy Markdown
Owner

Round-3 blocker resolved by semantically merging current origin/main (including merge 54a15cf) into the PR. Merge commit pushed: 9cb5413. The serializer retains adjacent user/toolResult coalescing, standalone string preservation unless coalescing, post-coalesce tool-loop-gated rolling checkpoint retention, and deferred-tools wire ordering (tool_result blocks, displaced sibling content, final coalesced text with cache_control).\n\nVerification on merged head:\n- Combined coalescing/deferred/native-replay/tool-reference/final-tool-pair plus checkpoint/cache-retention suites: 8 files passed, 87 passed, 14 skipped.\n- Root TypeScript check (): PASS.\n- Changelog gate (changelog-gate: PASS - changes.md coverage complete (0 production path(s) covered); no runtime source changes detected): PASS.\n- Full packages/ai suite: 2457 passed, 871 skipped; one unrelated workspace resolution failure in codex-apply-patch-wire-schema ( package entrypoint).\n\nThe branch push completed successfully; GitHub mergeability is being checked above.

@code-yeongyu

Copy link
Copy Markdown
Owner

Round-3 blocker resolved by semantically merging current origin/main (including merge 54a15cf) into the PR. The verified merge head is pushed on the contributor fork: be7371d.

The serializer retains adjacent user/toolResult coalescing, standalone string preservation unless coalescing, post-coalesce tool-loop-gated rolling checkpoint retention, and deferred-tools wire ordering (tool_result blocks, displaced sibling content, final coalesced text with cache_control).

Verification on the merged head:

  • Combined coalescing/deferred/native-replay/tool-reference/final-tool-pair plus checkpoint/cache-retention suites: 8 files passed, 87 passed, 14 skipped.
  • Root TypeScript check (npx tsc --noEmit): PASS.
  • Changelog gate (node scripts/check-pr-changelog.mjs --base origin/main): PASS.
  • Full packages/ai suite: 2457 passed, 871 skipped; one unrelated workspace resolution failure in codex-apply-patch-wire-schema (@earendil-works/pi-tui package entrypoint).

GitHub now reports the PR as MERGEABLE.

@code-yeongyu

Copy link
Copy Markdown
Owner

WORKING: review-1126-r4 - inspecting semantic merge and adversarial paths

@code-yeongyu

Copy link
Copy Markdown
Owner

WORKING: review-1126-r4 - exercising coalescing/checkpoint adversarial shapes on merged head

@code-yeongyu code-yeongyu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round-4 adversarial re-review of merged head be7371d09dda035433a1cced0242fe696cf2af08.

Merge-truth verdict:

(a) PASS - convertMessages() emits the assistant tool_use, convertToolResult() appends the tool_result, and the following user string is appended to that same user message. isToolLoopContinuation() then sees the coalesced tail plus preceding assistant tool use, and markUserMessageCacheCheckpoint() marks the final text block (not the tool result).

(b) PASS - ordinary user -> assistant -> user history remains three alternating messages and does not enter the rolling preceding-checkpoint path. With cache retention disabled, both standalone user strings remain content: string; with normal retention, only the normal final-user cache marker promotes the final string.

(c) PASS - three consecutive tool loops retain only the latest tool-result checkpoint and one preceding tool-result checkpoint (second and third loops); the first loop is not marked as part of the rolling pair. The existing checkpoint budget assertion remains satisfied.

(d) PASS - cacheRetention: "none" produces no cache markers, while standalone strings remain strings; coalesced interrupted content remains a block array only where required to represent the combined turn.

Merge-resolution checks: there is one definition each of appendUserBlocks, isToolLoopContinuation, and markUserMessageCacheCheckpoint; all user/tool-result append paths use the unified helper. The final coalesced-and-tool-loop-gated turn gets the marker on its final text block, and deferred-tool ordering remains tool_result blocks, displaced sibling content, then final coalesced text. packages/ai/CHANGELOG.md retains both #1075 checkpoint and #1126 coalescing entries under Unreleased/Fixed.

Verification: targeted Vitest coverage for coalescing, checkpoint stability, deferred tools, provider-native replay, tool-reference integrity, and final-tool-pair guard passed (6 files, 62 tests); LSP diagnostics are clean for the affected source/tests; the changelog gate passes. No new defensible blocker found.

APPROVE

@code-yeongyu
code-yeongyu merged commit c117303 into code-yeongyu:main Aug 29, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants